Skip to content

feat: implement cascading deletion for related records in delete #488

Open
kulikp1 wants to merge 29 commits intomainfrom
feature/AdminForth/1256/cascading-deletion-
Open

feat: implement cascading deletion for related records in delete #488
kulikp1 wants to merge 29 commits intomainfrom
feature/AdminForth/1256/cascading-deletion-

Conversation

@kulikp1
Copy link
Collaborator

@kulikp1 kulikp1 commented Feb 25, 2026

…oint

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds application-level cascading deletion support for related records in AdminForth's delete endpoint. When onDelete: 'cascade' or onDelete: 'setNull' is configured on a foreignResource column, deleting a parent record will either recursively delete child records or null-out the foreign key column in child records. The PR also adds detection of database-level ON DELETE CASCADE constraints in SQLite, PostgreSQL, and MySQL, with warnings when such constraints exist (to avoid conflicts with the new application-level cascade logic).

Changes:

  • New optional onDelete field ('cascade' | 'setNull') added to AdminForthForeignResource type, with validation in configValidator.ts
  • Core cascade deletion/null-out logic added to the /delete_record REST endpoint in restApi.ts
  • Database-level ON DELETE CASCADE detection and warning added to all three data connectors (SQLite, PostgreSQL, MySQL)

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
adminforth/types/Back.ts Adds onDelete field to AdminForthForeignResource interface
adminforth/modules/restApi.ts Implements cascade delete / setNull logic before deleting the parent record
adminforth/modules/configValidator.ts Validates the onDelete strategy value
adminforth/dataConnectors/sqlite.ts Detects DB-level ON DELETE CASCADE via PRAGMA foreign_key_list
adminforth/dataConnectors/postgres.ts Detects DB-level ON DELETE CASCADE via pg_constraint system tables
adminforth/dataConnectors/mysql.ts Detects DB-level ON DELETE CASCADE via information_schema

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1496 to +1500
await this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]);
}
} else if (onDeleteStrategy === 'setNull') {
for (const childRecord of childRecords) {
await this.adminforth.resource(childRes.resourceId).update(childRecord[childPkFieldName], {[foreignResourceColumn.name]: null});
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cascade deletion at line 1496 uses this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]), which calls OperationalResource.delete()connector.deleteRecord() directly, completely bypassing:

  1. The resource's delete hooks (hooks.delete.beforeSave and hooks.delete.afterSave)
  2. Permission checks (allowedActions.delete) for the child resource

This is a critical concern because these hooks are often used for cleanup of related artifacts (e.g., file storage cleanup in upload plugins, as referenced by the warning in the connector code). Additionally, the setNull path at line 1500 has the same issue — this.adminforth.resource(childRes.resourceId).update() similarly bypasses edit hooks for child records.

To trigger hooks and permissions, the implementation should use this.adminforth.deleteResourceRecord(...) for cascade deletions and this.adminforth.updateResourceRecord(...) for setNull operations, similar to how the parent record deletion is handled at line 1506.

Suggested change
await this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]);
}
} else if (onDeleteStrategy === 'setNull') {
for (const childRecord of childRecords) {
await this.adminforth.resource(childRes.resourceId).update(childRecord[childPkFieldName], {[foreignResourceColumn.name]: null});
const { error: childDeleteError } = await this.adminforth.deleteResourceRecord({
resource: childRes,
record: childRecord,
adminUser,
recordId: childRecord[childPkFieldName],
response,
extra: { body, query, headers, cookies, requestUrl, response }
});
if (childDeleteError) {
return { error: childDeleteError };
}
}
} else if (onDeleteStrategy === 'setNull') {
for (const childRecord of childRecords) {
const { error: childUpdateError } = await this.adminforth.updateResourceRecord({
resource: childRes,
record: childRecord,
adminUser,
recordId: childRecord[childPkFieldName],
updatedRecord: { [foreignResourceColumn.name]: null },
response,
extra: { body, query, headers, cookies, requestUrl, response }
});
if (childUpdateError) {
return { error: childUpdateError };
}

Copilot uses AI. Check for mistakes.
Comment on lines +1487 to +1500
const foreignResourceColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId);
if (!foreignResourceColumn.foreignResource.onDelete) continue;
const onDeleteStrategy = foreignResourceColumn.foreignResource.onDelete;
const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignResourceColumn.name, body['primaryKey']))
const childPkColumn = childRes.columns.find(col => col.primaryKey);
if (!childPkColumn) continue;
const childPkFieldName = childPkColumn.name;
if (onDeleteStrategy === 'cascade') {
for (const childRecord of childRecords) {
await this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]);
}
} else if (onDeleteStrategy === 'setNull') {
for (const childRecord of childRecords) {
await this.adminforth.resource(childRes.resourceId).update(childRecord[childPkFieldName], {[foreignResourceColumn.name]: null});
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At line 1487, childRes.columns.find(...) only returns the FIRST column in the child resource that has a foreignResource.resourceId matching the parent. If a child resource has multiple columns referencing the same parent resource (e.g., created_by and updated_by both pointing to users), only the first matching column will have its cascade/setNull strategy applied. The second FK column will be silently ignored.

Consider using childRes.columns.filter(...) to get all FK columns pointing to the parent resource, and then iterating over each one.

Suggested change
const foreignResourceColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId);
if (!foreignResourceColumn.foreignResource.onDelete) continue;
const onDeleteStrategy = foreignResourceColumn.foreignResource.onDelete;
const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignResourceColumn.name, body['primaryKey']))
const childPkColumn = childRes.columns.find(col => col.primaryKey);
if (!childPkColumn) continue;
const childPkFieldName = childPkColumn.name;
if (onDeleteStrategy === 'cascade') {
for (const childRecord of childRecords) {
await this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]);
}
} else if (onDeleteStrategy === 'setNull') {
for (const childRecord of childRecords) {
await this.adminforth.resource(childRes.resourceId).update(childRecord[childPkFieldName], {[foreignResourceColumn.name]: null});
const foreignResourceColumns = childRes.columns.filter(c => c.foreignResource?.resourceId === resource.resourceId);
if (!foreignResourceColumns.length) continue;
const childPkColumn = childRes.columns.find(col => col.primaryKey);
if (!childPkColumn) continue;
const childPkFieldName = childPkColumn.name;
for (const foreignResourceColumn of foreignResourceColumns) {
if (!foreignResourceColumn.foreignResource || !foreignResourceColumn.foreignResource.onDelete) {
continue;
}
const onDeleteStrategy = foreignResourceColumn.foreignResource.onDelete;
const childRecords = await this.adminforth.resource(childRes.resourceId).list(
Filters.EQ(foreignResourceColumn.name, body['primaryKey'])
);
if (onDeleteStrategy === 'cascade') {
for (const childRecord of childRecords) {
await this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]);
}
} else if (onDeleteStrategy === 'setNull') {
for (const childRecord of childRecords) {
await this.adminforth.resource(childRes.resourceId).update(
childRecord[childPkFieldName],
{ [foreignResourceColumn.name]: null }
);
}

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +100
const [fkResults] = await this.client.execute(`
SELECT
kcu.TABLE_NAME AS child_table,
kcu.COLUMN_NAME AS column_name,
rc.DELETE_RULE AS delete_rule
FROM information_schema.KEY_COLUMN_USAGE kcu
JOIN information_schema.REFERENTIAL_CONSTRAINTS rc
ON kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
AND kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
WHERE kcu.REFERENCED_TABLE_NAME = ?
AND kcu.TABLE_SCHEMA = DATABASE()
`, [resource.table]);

const fkMap: Record<string, { cascade: boolean; childTable: string }> = {};
for (const fk of fkResults as any[]) {
fkMap[String(fk.column_name)] = {
cascade: String(fk.delete_rule).toUpperCase() === 'CASCADE',
childTable: fk.child_table
};
if (fkMap[fk.column_name].cascade) {
afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`);
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MySQL query uses WHERE kcu.REFERENCED_TABLE_NAME = ? and selects kcu.COLUMN_NAME, which returns column names from CHILD tables that reference the current table (i.e., columns in other tables that point to this table as a parent). This is the opposite perspective from the SQLite implementation, which uses PRAGMA foreign_key_list(tableName) to return FK columns defined ON the current table (the child side).

The practical effect is that in MySQL, fkMap keys are column names from child tables rather than columns of the current table being discovered. For the warning logic this is functionally harmless since the warning fires as long as any cascade is found. However, it is semantically inconsistent with the SQLite approach and with the intent of discoverFields (which is supposed to characterize the fields of the current table). If fkMap is later used to set field properties (similar to SQLite's field.cascade = fkMap[row.name] || false), this inverted mapping would cause incorrect results.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

adminforth/modules/restApi.ts:1526

  • The deleteWithCascade method (line 190) already calls this.adminforth.deleteResourceRecord(...) for the top-level resource at the end, which performs the actual delete and fires before/after hooks. Then at lines 1521-1526, deleteWithCascade is called first, and immediately after, deleteResourceRecord is called again for the same record. This results in the record being deleted twice (which will likely cause a "record not found" error or silent no-op on the second call) and before/after delete hooks being fired twice for the top-level record.

The second call to deleteResourceRecord at lines 1523-1526 should be removed; deleteWithCascade already handles deleting the record and firing its hooks.

            await this.deleteWithCascade(resource, body.primaryKey, {body, adminUser, query, headers, cookies, requestUrl, response});

            const { error: deleteError } = await this.adminforth.deleteResourceRecord({ 
              resource, record, adminUser, recordId: body['primaryKey'], response, 
              extra: { body, query, headers, cookies, requestUrl, response } 
            });

adminforth/modules/configValidator.ts:301

  • The bulk delete action manually invokes beforeSave hooks (lines 263-279) and afterSave hooks (lines 288-301) around the call to deleteWithCascade. However, deleteWithCascade internally calls this.adminforth.deleteResourceRecord(...) (restApi.ts line 190), which itself runs all beforeSave and afterSave hooks for the resource (index.ts lines 758 and 781). This causes all delete hooks to fire twice for each record deleted via the bulk action: once explicitly in the bulk action and once inside deleteWithCascade/deleteResourceRecord.

The manual hook invocations in lines 263-279 and 288-301 should be removed from the bulk delete action, as deleteWithCascade (via deleteResourceRecord) already handles them.

            await Promise.all(
              (res.hooks.delete.beforeSave).map(
                async (hook) => {
                  const resp = await hook({ 
                    recordId: recordId,
                    resource: res as AdminForthResource, 
                    record, 
                    adminUser,
                    response,
                    adminforth: this.adminforth
                  }); 
                  if (!error && resp.error) {
                    error = resp.error;
                  }
                }
              )
            )

            if (error) {
              return;
            }
            
            const restApi = new AdminForthRestAPI (this.adminforth) 
            restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response, body: undefined, query: undefined, headers: undefined, cookies: undefined, requestUrl: undefined});

            await Promise.all(
              (res.hooks.delete.afterSave).map(
                async (hook) => {
                  await hook({ 
                    resource: res as AdminForthResource, 
                    record, 
                    adminUser,
                    recordId: recordId,
                    response,
                    adminforth: this.adminforth,
                  }); 
                }
              )
            )

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

adminforth/modules/configValidator.ts:301

  • In the bulk delete action handler (configValidator.ts), the code manually runs beforeSave hooks (lines 263-279), then calls deleteWithCascade (line 286) which internally calls deleteResourceRecord — which itself runs beforeSave hooks, deletes the record, and then runs afterSave hooks. Then the code manually runs afterSave hooks again (lines 288-301). This results in:
  1. beforeSave hooks executing twice for each deleted record
  2. afterSave hooks executing twice for each deleted record

The manual hook execution blocks (lines 263-279 and 288-301) should be removed, since deleteWithCascade (via deleteResourceRecord) already handles the complete lifecycle including hooks and deletion.

            await Promise.all(
              (res.hooks.delete.beforeSave).map(
                async (hook) => {
                  const resp = await hook({ 
                    recordId: recordId,
                    resource: res as AdminForthResource, 
                    record, 
                    adminUser,
                    response,
                    adminforth: this.adminforth
                  }); 
                  if (!error && resp.error) {
                    error = resp.error;
                  }
                }
              )
            )

            if (error) {
              return;
            }
            
            const restApi = new AdminForthRestAPI (this.adminforth) 
            await restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response});

            await Promise.all(
              (res.hooks.delete.afterSave).map(
                async (hook) => {
                  await hook({ 
                    resource: res as AdminForthResource, 
                    record, 
                    adminUser,
                    recordId: recordId,
                    response,
                    adminforth: this.adminforth,
                  }); 
                }
              )
            )

adminforth/modules/restApi.ts:1525

  • The delete_record endpoint calls deleteWithCascade (line 1520) and then immediately calls deleteResourceRecord again (lines 1522-1525) on the same parent record. deleteWithCascade already calls deleteResourceRecord internally (restApi.ts line 189), which runs beforeSave hooks, deletes the record from the database, and runs afterSave hooks. The second call at line 1522 will:
  1. Run beforeSave hooks a second time
  2. Attempt to delete a record that has already been deleted from the database
  3. Run afterSave hooks a second time

The second deleteResourceRecord call should be removed, as deleteWithCascade already handles the deletion of the parent record.

            await this.deleteWithCascade(resource, body.primaryKey, {adminUser, response});

            const { error: deleteError } = await this.adminforth.deleteResourceRecord({ 
              resource, record, adminUser, recordId: body['primaryKey'], response, 
              extra: { body, query, headers, cookies, requestUrl, response } 
            });

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +45 to +49
const fkStmt = this.client.prepare(`PRAGMA foreign_key_list(${tableName})`);
const fkRows = await fkStmt.all();
const fkMap: { [colName: string]: boolean } = {};
fkRows.forEach(fk => {
fkMap[fk.from] = fk.on_delete?.toUpperCase() === 'CASCADE';
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SQLite's PRAGMA foreign_key_list(tableName) returns the FK constraints defined in tableName — i.e., where tableName is the child/dependent table. So fkMap[fk.from] maps columns in the current table that reference other (parent) tables, and checks if they have ON DELETE CASCADE.

However, the warning aims to detect conflicts where a child table targeting the current (parent) resource already has a DB-level ON DELETE CASCADE — which would double-delete when adminForth's cascade deletion also runs. To detect that conflict correctly, you need to check tables that reference the current table as their parent (the reverse direction).

In contrast, the PostgreSQL and MySQL queries correctly filter by the current tableName as the referenced/parent table and find child tables that cascade-delete on the current table's row deletion.

The SQLite check should use a different approach to detect child tables that reference the current table with ON DELETE CASCADE (e.g., check PRAGMA foreign_key_list on all other discovered tables and look for references to the current table).

Copilot uses AI. Check for mistakes.
Comment on lines +97 to +105
const fkMap: Record<string, { cascade: boolean; targetTable: string }> = {};

for (const row of res.rows) {
fkMap[row.column_name.toLowerCase()] = {
cascade: row.confdeltype === 'c',
targetTable: row.parent_table,
};
}
return fkMap;
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fkMap returned from getPgFkCascadeMap is keyed by column_name from child tables (not the current table being discovered). When multiple child tables reference the current table using differently-named columns (e.g., child_a.user_id and child_b.user_id), the last write would overwrite the earlier entry. However, since the map is only used to check if any cascade exists (line 112), collisions don't affect correctness of the warning. But the map itself contains misleading data — it represents child-table FK columns, not the current table's own columns — and the variable naming (targetTable refers to the parent, not the child) adds to the confusion. The entire fkMap structure is unnecessary; since only the cascade boolean is needed for the warning, a simpler Set<string> or direct boolean flag would be cleaner.

Copilot uses AI. Check for mistakes.
Comment on lines +155 to +189
async deleteWithCascade(resource: AdminForthResource, primaryKey: string, context: {adminUser: any, response: any}) {
const { adminUser, response } = context;

const record = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(resource, primaryKey);

if (!record) return;

const childResources = this.adminforth.config.resources.filter(r =>r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId));

for (const childRes of childResources) {
const foreignColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId);

if (!foreignColumn?.foreignResource?.onDelete) continue;

const strategy = foreignColumn.foreignResource.onDelete;

const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignColumn.name, primaryKey));

const childPk = childRes.columns.find(c => c.primaryKey)?.name;
if (!childPk) continue;

if (strategy === 'cascade') {
for (const childRecord of childRecords) {
await this.deleteWithCascade(childRes, childRecord[childPk], context);
}
}

if (strategy === 'setNull') {
for (const childRecord of childRecords) {
await this.adminforth.resource(childRes.resourceId).update(childRecord[childPk], {[foreignColumn.name]: null});
}
}
}

await this.adminforth.deleteResourceRecord({resource, record, adminUser, recordId: primaryKey, response});
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deleteWithCascade ignores the return value of deleteResourceRecord (line 189), which returns { error: string | null }. If a beforeSave hook returns an error or the DB delete fails, the error is silently discarded. Callers of deleteWithCascade (including the delete_record endpoint and the bulk delete action) have no way to know whether deletion actually succeeded.

The method signature should return a result indicating success/failure (e.g., Promise<{ error: string | null }>), and callers should check the returned error and halt the operation if one is present. In the cascade case (line 178), if a child deletion fails, the parent should not be deleted.

Suggested change
async deleteWithCascade(resource: AdminForthResource, primaryKey: string, context: {adminUser: any, response: any}) {
const { adminUser, response } = context;
const record = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(resource, primaryKey);
if (!record) return;
const childResources = this.adminforth.config.resources.filter(r =>r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId));
for (const childRes of childResources) {
const foreignColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId);
if (!foreignColumn?.foreignResource?.onDelete) continue;
const strategy = foreignColumn.foreignResource.onDelete;
const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignColumn.name, primaryKey));
const childPk = childRes.columns.find(c => c.primaryKey)?.name;
if (!childPk) continue;
if (strategy === 'cascade') {
for (const childRecord of childRecords) {
await this.deleteWithCascade(childRes, childRecord[childPk], context);
}
}
if (strategy === 'setNull') {
for (const childRecord of childRecords) {
await this.adminforth.resource(childRes.resourceId).update(childRecord[childPk], {[foreignColumn.name]: null});
}
}
}
await this.adminforth.deleteResourceRecord({resource, record, adminUser, recordId: primaryKey, response});
async deleteWithCascade(
resource: AdminForthResource,
primaryKey: string,
context: { adminUser: any; response: any }
): Promise<{ error: string | null }> {
const { adminUser, response } = context;
const record = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(resource, primaryKey);
// If there is nothing to delete, treat this as a successful no-op.
if (!record) {
return { error: null };
}
const childResources = this.adminforth.config.resources.filter(r =>
r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)
);
for (const childRes of childResources) {
const foreignColumn = childRes.columns.find(
c => c.foreignResource?.resourceId === resource.resourceId
);
if (!foreignColumn?.foreignResource?.onDelete) continue;
const strategy = foreignColumn.foreignResource.onDelete;
const childRecords = await this.adminforth
.resource(childRes.resourceId)
.list(Filters.EQ(foreignColumn.name, primaryKey));
const childPk = childRes.columns.find(c => c.primaryKey)?.name;
if (!childPk) continue;
if (strategy === 'cascade') {
for (const childRecord of childRecords) {
const childResult = await this.deleteWithCascade(
childRes,
childRecord[childPk],
context
);
if (childResult?.error) {
// If a child deletion fails, do not delete the parent.
return childResult;
}
}
}
if (strategy === 'setNull') {
for (const childRecord of childRecords) {
await this.adminforth
.resource(childRes.resourceId)
.update(childRecord[childPk], { [foreignColumn.name]: null });
}
}
}
const deleteResult = await this.adminforth.deleteResourceRecord({
resource,
record,
adminUser,
recordId: primaryKey,
response,
});
return deleteResult;

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is important

const record = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(resource, primaryKey);

if (!record) return;
if (!record){ return {error: null};}
Copy link
Collaborator

@SerVitasik SerVitasik Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe better is to tell something to user?
Or do it like was just return

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

adminforth/modules/configValidator.ts:301

  • In the bulk delete action, beforeSave hooks are manually executed at lines 263–279, followed by deleteWithCascade at line 286, followed by manual afterSave hook execution at lines 288–301.

However, deleteWithCascade internally calls deleteResourceRecord (see restApi.ts line 191), which itself runs all beforeSave and afterSave delete hooks for the parent resource. This causes every delete hook defined on the parent resource to be invoked twice per bulk-deleted record.

The fix should either:

  • Remove the manual hook calls (lines 263–279 and 288–301) since deleteWithCascade already handles them, OR
  • Refactor deleteWithCascade to only cascade child operations (not delete the parent), and rely on the caller to invoke deleteResourceRecord for the parent.
            await Promise.all(
              (res.hooks.delete.beforeSave).map(
                async (hook) => {
                  const resp = await hook({ 
                    recordId: recordId,
                    resource: res as AdminForthResource, 
                    record, 
                    adminUser,
                    response,
                    adminforth: this.adminforth
                  }); 
                  if (!error && resp.error) {
                    error = resp.error;
                  }
                }
              )
            )

            if (error) {
              return;
            }
            
            const restApi = new AdminForthRestAPI (this.adminforth) 
            await restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response});

            await Promise.all(
              (res.hooks.delete.afterSave).map(
                async (hook) => {
                  await hook({ 
                    resource: res as AdminForthResource, 
                    record, 
                    adminUser,
                    recordId: recordId,
                    response,
                    adminforth: this.adminforth,
                  }); 
                }
              )
            )

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +111 to +115
const fkMap = await this.getPgFkCascadeMap(tableName);
const hasCascade = Object.values(fkMap).some(fk => fk.cascade);
if (hasCascade) {
afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`);
}
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the MySQL connector, the Postgres fkMap built in getPgFkCascadeMap (lines 97–105) is used only to emit the hasCascade warning (lines 112–115) and is never used to set a field.cascade property on individual discovered fields. The SQLite connector sets field.cascade per field, but Postgres does not, leading to an inconsistency across connectors.

Copilot uses AI. Check for mistakes.
Comment on lines 1523 to 1528
}

await this.deleteWithCascade(resource, body.primaryKey, {adminUser, response});

const { error: deleteError } = await this.adminforth.deleteResourceRecord({
resource, record, adminUser, recordId: body['primaryKey'], response,
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deleteWithCascade method (line 191) already calls this.adminforth.deleteResourceRecord(...) internally, which performs the actual DB deletion and runs both beforeSave and afterSave delete hooks. Then, immediately after calling deleteWithCascade at line 1523, deleteResourceRecord is called again at lines 1525–1528 for the same parent record.

This causes two problems:

  1. The parent record is deleted twice — the second deletion either silently no-ops or throws a "record not found" error.
  2. Both beforeSave and afterSave delete hooks fire twice for the parent resource.

The fix is to remove the redundant deleteResourceRecord call at lines 1525–1528, since the deletion and hook execution is already handled inside deleteWithCascade.

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +101
const fkMap: Record<string, { cascade: boolean; childTable: string }> = {};
for (const fk of fkResults as any[]) {
fkMap[String(fk.column_name)] = {
cascade: String(fk.delete_rule).toUpperCase() === 'CASCADE',
childTable: fk.child_table
};
if (fkMap[fk.column_name].cascade) {
afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`);
}
}
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MySQL fkMap built at lines 92–101 is keyed on child-table column names (columns from other tables that reference this table), yet it is never used to annotate any field property in the current table's fieldTypes. Only the warning is emitted when cascade is true.

By contrast, the SQLite connector (sqlite.ts line 96) sets field.cascade = fkMap[row.name] || false on each discovered field. The Postgres connector has the same inconsistency — the fkMap from getPgFkCascadeMap is used only for the warning, never to set field.cascade.

If field.cascade is not needed for Postgres and MySQL, the SQLite connector should also drop field.cascade and the logic should be consistent. If field.cascade is needed, the MySQL and Postgres connectors need equivalent per-field annotation.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants